package net.wanderinghorse.jsonmessage.poster;

import net.wanderinghorse.json.CookieList;
import net.wanderinghorse.json.JSONException;
import net.wanderinghorse.json.JSONObject;
import net.wanderinghorse.json.JSONUtil;
import net.wanderinghorse.jsonmessage.*;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * A RequestPoster implementation which posts a Request via HTTP.
 *
 * This implementation has very rudimentary cookies support, which allows is to
 * keep a remote HTTP session alive as long as the session manager passes its
 * session ID around using a cookie. All cookies sent from a given dispatcher URL
 * are managed for the lifetime of this object and are returned to the server
 * on each subsequent post to that URL.
 *
 * Limitations:
 *
 * - See AbstractRequestPoster.
 *
 * - Only supports HTTP[S] POST, not GET.
 *
 * - Proxy support: it uses JSONUtil.getConfigObject() to fetch a JSON configuration
 * file which may contain proxy server info in the form {proxy:{host:"...",port:8080}}.
 * If it is not found then no proxy is used by default, but setHttpProxy() may be used
 * to specify one.
 *
 * - Timeout option is not completely honored. The URLConnection API (which is abstract)
 * only supports setting a READ timeout, and not a CONNECT/WRITE timeout,
 * and there are no non-abstract subclasses of it in the standard Java
 * API. The HttpURLConnection type separates read/connect timeouts, but
 * it's abstract. Even if it weren't, we'd have to manually calculate
 * the time it took to connect vs. the read time, and time out accordingly.
 * Bummer.
 *
 * More info about timeouts:
 *
 * http://www.twmacinta.com/myjava/ucon_timeout.php
 *
 * http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4700777
 * in that bug report they claim a fix was released, if they call
 * the current (1.6) API a "fix" then they've got a funny sense of "fixed."
 *
 * http://www.velocityreviews.com/forums/t130657-httpurlconnection-timeout-solution.html
 */
public class HttpRequestPoster extends AbstractRequestPoster {
    private Proxy proxy = getProxyFromConfig();
    private final Logger log = Logger.getLogger(getClass().getName());
    private boolean manageCookies = true;
    /**
     * A map of cookie keys to cookie values.
     */
    public static class CookieMap extends HashMap<String,String> {}

    /**
     * A map of URL-to-CookieMap
     */
    public static class CookieJar extends HashMap<String,CookieMap> {}

    private final CookieJar cookies = new CookieJar();

    /**
     * Default-initializes this object.
     */
    public HttpRequestPoster() {
    }

    /**
     * @return The Proxy object used for HTTP proxying, or null
     * if no proxy has been set (or it has subsequently unset).
     * Not thread-safe.
     */
    public Proxy getHttpProxy() {
        return proxy;
    }

    /**
     * @return True if this object has cookie management enabled,
     * else false.
     */
    public boolean getManagesCookies() {
        return manageCookies;
    }

    /**
     * @param manageCookies If true then this object will try to
     * interpret Set-Cookie requests from remote servers, and will send
     * all such cookies back on each post to that same URL. If false
     * then any accumulated cookies are cleared and cookie management
     * is disabled.
     */
    public void setManagesCookies(final boolean manageCookies) {
        if( ! manageCookies && this.manageCookies ) {
            this.clearCookies();
        }
        this.manageCookies = manageCookies;
    }

    private Proxy getProxyFromConfig() {
        JSONObject conf = JSONUtil.getConfigObject(getClass());
        if( null == conf ) return null;
        conf = conf.optJSONObject("proxy");
        if( null == conf ) return null;
        //final Object hostObj = conf.opt("host");
        final String host =
                //JSONObject.NULL.equals(hostObj) ? null : hostObj.toString()
                conf.optString("host",null);
        if( null == host || host.isEmpty() ) return null;
        final int port = conf.optInt("port",-1);
        if( port <= 0 ) return null;
        return new Proxy( Proxy.Type.HTTP, new InetSocketAddress( host, port ) );
    }

    /**
     * Returns a mutable map of any cookies this object has collected
     * from remote URLs.
     *
     * BIG FAT ACHTUNG: the cookies support pays no attention whatsoever
     * to cookie domain paths, lifetimes, etc. The only distinction it makes
     * is to group cookies based on their request URL.
     *
     * ACHTUNG #2: malformed cookie keys or values may cause undefined
     * behaviour.
     *
     * @return See above.
     */
//    public CookieJar getCookies() {
//        return cookies;
//    }

    /**
     * Sets the HTTP proxy used by this object.
     *
     * @param proxy The proxy object. If null then proxying is
     * disabled.
     */
    public void setHttpProxy(final Proxy proxy) {
        synchronized ( this ) {
            this.proxy = proxy;
        }
    }

    /**
     * Sets the HTTP proxy used by this object.
     *
     * @param host The proxy host. If it is null or empty then proxying
     * is disabled.
     * @param port The port number for the proxy.
     */
    public void setHttpProxy( final String host, final int port ) {
        setHttpProxy( ( (null == host) || host.isEmpty() )
                  ? null
                  : new Proxy( Proxy.Type.HTTP, new InetSocketAddress( host, port ) )
                );
    }

    private static String escapeCookie(final String val ) {
        return net.wanderinghorse.json.Cookie.escape(val);
    }

    private static String unescapeCookie(final String val ) {
        return net.wanderinghorse.json.Cookie.unescape(val);
    }

    private URLConnection prepareUrlPost( final String url, final int timeout )
    throws IOException {
       /*
        TODO: see if we can re-implement this using sun.www.http.HttpClient,
        with the hope being that we could then implement cookie support so we
        can store remote session IDs and whatnot.
       */
        final URL where = new URL( url );
        URLConnection con = (null == proxy)
            ? where.openConnection()
            : where.openConnection( proxy );
        con.setDoOutput( true );
        if( 0 <= timeout ) {
            con.setConnectTimeout( timeout );
            con.setReadTimeout( timeout );
        }
        //if( (null != jsessIdKey) && (null != jsessId) ) {
        //    con.addRequestProperty("Cookie",jsessIdKey+"="+jsessId);
        //}

        synchronized( this.cookies ) {
            if( manageCookies ) {
                CookieMap cmap = cookies.get(url);
                if( (null != cmap) && !cmap.isEmpty() ) {
//                    for( Map.Entry<String,String> e : cmap.entrySet() ) {
//                        // Is this an acceptable way to set cookies, or must i pass them
//                        // all pasted together as a single entry?
//                        // We should try CookieList.toString() here and see what happens.
//                        con.addRequestProperty("Cookie",e.getKey()+"="+escapeCookie(e.getValue()));
//                    }
                    con.addRequestProperty("Cookie", CookieList.toString(new JSONObject(cmap)));
                }
            }
        }
        return con;
    }

    private static final Pattern patternSetCookie = Pattern.compile("(\\w+)=([^;]*)(;.*)?");

    /**
     * If headers.get("Set-Cookie") returns a non-empty list then this routine tries to fish
     * out any Set-Cookie key/value pairs and store them in the internal cookie jar. The cookies
     * are keyed to the given URL, which may not be null.
     *
     * If !manageCookies then this is a no-op.
     *
     * @param url The URL under which to file the cookies.
     * @param headers Incoming request headers, which may be null.
     */
    private void handleIncomingCookies( final String url, final Map<String, List<String>> headers ) {
        if( ! manageCookies ) return;
        final List<String> cvals = (null==headers) ? null : headers.get("Set-Cookie");
        if( (null == cvals) || cvals.isEmpty() ) return;
        synchronized( this.cookies ) {
            CookieMap cmap = cookies.get(url);
            if( (null == cmap) ) {
                cmap = new CookieMap();
                cookies.put(url, cmap);
            }
            log.fine("We seem to have gotten cookies to set...");
            // FIXME: use JDK1.6's HttpCookie.parse() to deal with these,
            for( final String cval : cvals ) {
                final Matcher m = patternSetCookie.matcher(cval);
                if( ! m.matches() ) continue;
                final String key = m.group(1);
                final String val = m.group(2);
                if( (null==val) || val.isEmpty() ) {
                    cmap.remove(key);
                }
                else {
                    cmap.put( key, unescapeCookie(val) );
                }
                log.fine("We got a cookie: ["+key+"]=["+val+"]");
            }
        }
    }

    /**
     * Posts r to opt.getUrl() and awaits a JSONMessage response. Once it gets a response it;
     *
     * - If this object is managing cookies, it parses any Set-Cookie requests from the
     * remote server and stores them in the internal cookie jar.
     *
     * - It returns the fetched JSON to the caller.
     *
     * @param r The request to post.
     * @param opt The framework-conventionan post() options, which must contain a valid URL.
     * @param useEncodedForm If true, the data is posted in the "old format" (urlencoded form data),
     * else raw JSON is posted (this is preferred, as it is far more efficient).
     * @return The fetched response string, which will hopefully be JSONMessage-formatted JSON.
     * @throws IOException If the URL is malformed or the underlying I/O operations throw.
     */
    private String fetchResponseJSON( final Request r, final PostOptions opt, final boolean useEncodedForm )
    throws IOException {

        final Integer ti = opt.getTimeout();
        URLConnection con = prepareUrlPost( opt.getUrl(),(null==ti)?0:ti );
        con.setRequestProperty("Content-Type", useEncodedForm ? "application/x-www-form-urlencoded" : "text/plain");
        con.connect();
        final OutputStreamWriter wri = new OutputStreamWriter(con.getOutputStream());
        if( useEncodedForm ) {
            wri.write( Request.REQUEST_KEY+"=" );
            wri.write( URLEncoder.encode( r.toJSON(), "UTF-8" ) );
        }
        else {
            wri.write( r.toJSON() );
        }
        wri.flush();
        try { wri.close(); } catch( Exception ex ) {/*ignore*/}
        StringBuilder json = null;
        BufferedReader rd = new BufferedReader( new InputStreamReader( con.getInputStream() ) );
        {
            json = new StringBuilder();
            String line;
            while( null != (line = rd.readLine()) ) {
                json.append(line);
            }
        }
        try { rd.close(); } catch( Exception ex ) {/*ignore*/}
        handleIncomingCookies( opt.getUrl(), con.getHeaderFields() );
        return json.toString();
    }

    /**
     * See fetchResponseJSON().
     *
     * @param req The request object to post.
     * @param opt The conventional post options.
     * @return The fetched Response object.
     * @throws IOException If fetchResponseJSON() throws.
     * @throws JSONException If the inbound data does not represent a JSON-serialized
     * Response object.
     */
    private Response fetchResponse( final Request req, final PostOptions opt )
    throws IOException, JSONException {
        final String json = fetchResponseJSON( req, opt, false );
        Response res = new Response();
        res.fromJSON(json);
        return res;
    }
    /**
      * Tries to post the given request over HTTP POST using the given options.
      * See the class-level docs for limitations.
      *
      * Any exceptions will be passed to the opt.getOnError() callback,
      * and are not propagated by this function.
      *
      * @param r The request to post.
      * @param opt The connection options to use for the request.
      */
     @Override
     protected void doPost( Request r, PostOptions opt) throws Exception {
        Response res;
        try {
            res = fetchResponse( r, opt );
            if(null == res) {
                throw new InternalError("This should never happen!");
            }
//            JSONObject test = new JSONObject();
//            test.put( "cookies",new JSONObject(cookies));
//            res.setOptions( test );
        }
        catch( Exception ex ) {
            opt.setErrorMessage("Exception ("+ex.getClass().getName()+") while fetching response data: "+ex.toString());
            onPostError( r, opt );
            return;
        }
        onResponse( r, res, opt );
    }

    /**
     * Removes all cookies associated with this object which were sent
     * from the given URL. Note that the URL must exactly match the one used by
     * post() operations, as incoming cookies are bound to that URL.
     *
     * @param url The URL to clear all cookies for.
     */
    public void clearCookies( final String url ) {
        cookies.remove(url);
    }

    /**
     * Removes ALL cookies associated with this object.
     */
    public void clearCookies() {
        cookies.clear();
    }
}
